Содержание статьи
Турбопередача стековых аргументов
Передачу аргументов через стек можно существенно ускорить, в случае если аргументы представляют собой константу, известную еще на стадии трансляции. Классический способ передачи выглядит так:
Классический способ передачи стековых аргументов
00000000: 6869060000 push 00000066900000005: 6899090000 push 0000009990000000A: 6896060000 push 0000006960000000F: E852060000 call 000000666Довольно расточительное (в плане процессорных тактов) решение, особенно если функция вызывается многократно. При этом операнды команды PUSH перегоняются из секции . (находящейся в кодовой кэш‑памяти первого уровня) в область стека, находящуюся в кэш‑памяти данных. Ну и зачем гонять их туда и обратно, когда аргументы можно использовать непосредственно по месту хранения?
Усовершенствованный пример выглядит так:
.codeMOV EBP, ESPMOV ESP, offset func_arg + 4CALL my_funcMOV ESP, EBP.datafunc_arg DD 00h, 696h, 999h, 669hИ хотя размер кода после оптимизации не только не сократился, но даже увеличился (14h байт до оптимизации и 1Eh - после), мы сохранили немного стековой памяти и сократили время выполнения. Причем чем больше аргументов передается функции, тем в более выигрышном положении оказывается оптимизированный вариант, поскольку неоптимизированный вынужден тратить на каждый аргумент один дополнительный байт!
00000000: 8BEC mov ebp, esp00000002: BC66000000 mov esp, 00000001300000007: E80E000000 call 0000006660000000C: 8BE5 mov esp, ebp…0000000E: 00 00 00 00 96 06 00 00 ? 99 09 00 00 69 06 00 000000001E:Несколько замечаний по поводу. Первое. Операционные системы семейства Windows NT (к которым принадлежат Windows 2000, Windows XP, Windows Vista, Windows Server 2003 и Windows Server Longhorn) гарантируют целостность содержимого стека выше его вершины (для адресов меньших, чем ESP), поэтому переносят такие извращения безо всякого ущерба для работоспособности программы. Операционные системы семейства Windows 9x ведут себя иначе, бесцеремонно используя все, что находится выше ESP в целях «производственной необходимости», что ведет к искажению секции данных и последующему краху программы. Поэтому все, что было сказано здесь, распространяется только на NT.

Замечание номер два. Перед аргументами необходимо оставить двойное слово (а в 64-битном режиме — четвертное) для сохранения адреса возврата. При этом секция данных, где находится это слово, должна быть доступна на запись. Если же функция вызывается из одного единственного места и адрес возврата известен заранее, ничего не мешает положить его рядом с аргументами. Но тогда функцию придется пускать командой jump, а не call, что еще больше увеличивает производительность:
Вызов функции с предопределенным адресом возврата командой JMP
.codeMOV EBP, ESPMOV ESP, offset func_arg + 4JMP my_funchere:MOV ESP, EBP.datafunc_arg DD offset here, 696h, 999h, 669hКстати говоря, ни адрес возврата, ни аргументы функции вовсе не обязаны быть константами, известными на стадии компиляции, и они могут свободно модифицироваться в любой момент командами MOV и STOS. Также если аргументы хранятся в локальных переменных, то засылать их в стек не обязательно! Достаточно лишь скорректировать регистр ESP таким образом, чтобы переменные‑аргументы оказались на вершине. Естественно, порядок размещения аргументов в памяти должен совпадать с порядком передачи аргументов, но на ассемблере, в отличие от языков высокого уровня, мы можем самостоятельно выбирать нужную схему размещения переменных, так что это не проблема.
Еще одна тонкость: «оптимизированный» вариант обладает всеми формальными атрибутами «передачи по значению», но де‑факто аргументы передаются по ссылке. То есть совсем наоборот! Аргументы передаются по значению, но это значение после выхода из функции сохраняет свое состояние, ведет себя так, как будто бы оно было передано по ссылке. Иногда это экономит такты процессора и сокращает потребности в памяти, но иногда ведет к трудноуловимым ошибкам, лишний раз подтверждая тезис, что нет в мире совершенства.
И последнее: при всех этих играх со стеком следует помнить, что целый ряд API-функций требует, чтобы указатель стека был выровнен на границу четырех байтов. Нарушение этого правила ведет к непредсказуемым последствиям.
Повторное использование кадра стека
При входе внутрь функции большое количество локальных переменных инициализируется константами или значениями, инвариантными по отношению к самой функции (то есть другими переменными, чаще всего глобальными). Причем инициализация обычно осуществляется командой MOV, а для обслуживания строковых переменных приходится прибегать к REP . Все это медленно, громоздко и непроизводительно.
А почему бы не подготовить кадр стека еще на стадии трансляции?! В грубом приближении это будет выглядеть так:
Вызов функции с заранее подготовленными аргументами и локальными переменными
.codeMOV EBP, ESPMOV ESP, offset func_argJMP my_funcMOV ESP, EBP…my_func:MOV EBP,ESPSUB ESP, offset func_locals - offset return_address………MOV ESP,EBPRETN.datafunc_locals:var_1 DB 66hvar_2 DD offset globalFlagvar_s DB "hello",0var_x DD 0var_y DD 0return_address:DD 00hfunc_args:DD 696h, 999h, 669hВ некоторых случаях достигается просто колоссальное ускорение, однако тут есть один подводный камень — при повторном вызове функции все «инициализированные» переменные сохраняют свои текущие значения и наступает полный облом. Фактически мы добились того, что превратили локальные стековые переменные в статические! Бесспорно, иногда это очень хорошо, но в 90% случаев нам нужно совсем другое. Вот и устроим себе это другое с помощью REP ! Подготавливаем инициализированные локальные переменные на стадии создания ассемблерной программы, а затем копируем их в кадр функции при его открытии. Это намного быстрее, чем инициализировать каждую локальную переменную по отдельности командой MOV.
К тому же кадры некоторых функций достаточно схожи между собой, что позволяет объединить несколько кадров в один! Достаточно сказать, что каждая функция нуждается в переменных, инициализированных нулями. Чтобы не делать много раз один и тот же MOV [ лучше (и быстрее) выполнить REP !
Вот в чем истинная сила ассемблера! Вот извращения, недоступные языкам высокого уровня, но… самые зверские издевательства еще впереди!!!
Защита адреса возврата от переполнения
Проблема переполняющихся буферов породила огромное количество червей, открыв безграничный простор для хакерских атак. Но, несмотря на все ухищрения, предпринятые как со стороны производителей компиляторов, так и со стороны разработчиков операционных систем, она остается нерешенной и по сей день.
Ассемблер предоставляет по меньшей мере 2 надежных механизма, до которых компиляторы еще не «додумались». Первый и самый простой — это 2 стека: один для хранения адресов возврата, другой - для передачи аргументов и локальных переменных. Кстати говоря, существуют процессорные архитектуры, в которых этот механизм реализован изначально. Но x86-семейство к ним, увы, не относится, поэтому приходится брать в лапы напильник и точить.
Для организации двух раздельных стеков нам требуется всего лишь 1 дополнительный регистр (который можно выделить из пула регистров общего назначения). Пусть это будет регистр EBP, указывающий на стек с локальными переменными. Собственно говоря, неправильно называть его стеком, поскольку в операционных системах семейства Windows стек представляет собой особый регион памяти, подпираемый сверху сторожевой страницей page-guard. Мы же разместим свой стек в памяти, полученной функцией VirtualAlloc или, если хочется оптимизации, в .BSS-секции PE-файла, выделение которой обходится очень дешевого (в плане машинного времени). Но это все детали реализации. Будем считать, что ESP указывает на нормальный стек, а EBP — на рукотворный. Как тогда будет происходить вызов функций и передача аргументов?
А вот так:
; // подготовительные операцииMOV EBP, [XXX] ; XXX - указатель на рукотворный стекMOV ESP, ESP ; ;-)…; // передача аргументов функцииMOV [EBP+00h], arg_aMOV [EBP+04h], arg_bMOV [EBP+08h], arg_c// вызов самой функцииCALL func…// ================================================================================; // реализация самой функцииfunc:ADD EBP, local_var_size ; резервируем память под локальные переменныеMOV ECX, [EBP-local_var_size+04h] ; загрузка аргумента arg_b в регистр ECXMOV ESI, [EBP-local_var_size+08h] ; загрузка аргумента arg_c в регистр ESIMOV EDI, EBP ; грузим в EDI указатель на конец области локальных переменныхSUB EDI, local_var_size ; вычисляем указатель на локальный буфер; (в данном случае он расположен по смещению 00h; относительно фрейма)REP MOVSB ; копируем arg_b байтов из arg_c в локальный буфер; // делаем еще что-то полезноеRET ; выходим из функцииРукотворный стек с локальными переменными и аргументами растет сверху вниз, то есть в направлении, противоположном росту обычного стека, и это неспроста. Во‑первых, подсистема памяти IBM PC и операционная система Windows оптимизированы именно под такое выделение памяти и мы получаем выигрыш в производительности. Во‑вторых, внизу рукотворного стека находится неинициализированная область памяти, что делает ошибки переполнения неактуальными. Затираются лишь локальные переменные текущей функции, да и то лишь те, которые лежат ниже переполняющегося буфера.
Адреса возврата хранятся в другом месте и на них эти переполнения не распространяются, если, конечно, натуральный стек расположен выше рукотворного.
Основную трудность представляет засылка аргументов в рукотворный стек. Под MS-DOS мы могли выделить отдельный сегмент и использовать PUSH с префиксом «GS:», а под Windows приходится применять MOV [. При этом адресации типа «память – память» в x86-процессорах не было и нет. В практическом плане это означает, что нам придется использовать промежуточные регистры: MOV . Впрочем, это можно оптимизировать, если использовать команду STOSD, занимающую в «машинном представлении» всего один байт и копирующую содержимое EAX в ячейку, на которую указывает EDI, одновременно с увеличением последнего на размер двойного слова. Стаскивать аргументы с рукотворного стека можно командой LODSD.
Окончательно расхулиганившись, можно создать целых 3 стека: один - стандартный, для хранения адресов возврата, другой — для аргументов и третий - для локальных переменных. Чтобы не расходовать регистры понапрасну, можно хранить указатели на вершины двух рукотворных стеков в оперативной памяти, загружая их то в регистр EBP, то в ESI/EDI, в зависимости от того, какой из них окажется удобнее в тот или иной момент. Падения производительности можно не опасаться. Большую часть своего времени указатели будут проводить в кэш‑памяти, извлекаясь всего за 1-2 такта.
Естественно, все сказанное выше, относится только к нашим собственным функциям, а API-функции операционной системы таких извращений не понимают и ожидают аргументов в стандартном стеке. Ну, что тут можно сказать… Персонально для API-функций аргументы можно передать и в стандартном стеке, предварительно убедившись, что при этих аргументах функция гарантированно не вызовет переполнения (что вовсе не факт, особенно при работе с функциями из библиотеки mshtml.dll). К тому же в 64-битной редакции Windows аргументы API-функциями в большинстве случаев передаются не через стек, а через регистры, поэтому описанная методика к ним вполне применима.
А вот как защитить от переполнения функции обычных библиотек? Самое простое решение — вызвать функции не по CALL, а по JMP, разместив адрес возврата на вершине страницы памяти, доступной только на чтение. Ниже ее будут только аргументы, также доступные только на чтение, а вот локальные переменные, создаваемые функцией, будут доступны и на чтение, и на запись. Естественно, этот трюк будет работать только с теми функциями, которые не изменяют своих аргументов (а многие из них изменяют их только так), но по‑другому просто не получается!









